React 性能优化方案探讨

对于一个产品来说,性能问题是很影响用户体验的。为了提升性能,各个框架也是在不断地努力改进。比如 React v16 开始使用 Fiber,通过时间切片的方式来改善原来在调和阶段(Reconciler),使用递归生成完整 Virtual DOM 树而导致渲染引擎被阻塞的问题。

框架的归框架,开发的归开发。开发能在这个基础上再做些什么性能优化呢?本文以 React 为例。


React 官网其实已经专门用一个页面来介绍如何做性能优化了:链接。我们来逐一看看。

使用生产版本

为什么要使用生产版本呢?官方给出的解释说,React 内部提供了很多错误处理辅助,这些对开发来说非常有用,但无疑让 React 本身变得庞大、缓慢。在使用生产版本后,就不会有这方面的问题。

Webpack 配置

如果是使用 Webpack,按照这个方式配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack v4
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
mode: 'production'
optimization: {
minimizer: [new TerserPlugin({ /* additional options here */ })],
},
};

// webpack v3
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
})

确认使用版本

如何确认正在使用的版本?我们需要先安装插件 React Developer Tools for Chrome

如果我们在访问页面的时候,发现是这个样子的,就说明是生产版本:
生产版本下的插件示意图

如果是这个样子的,就说明是开发版本:
开发版本下的插件示意图

使用 Chrome 性能分析工具

Chrome 性能分析工具大家应该都比较熟悉了,它是我们最常用的。在开发模式下,我们可以在这里看到组件是如何 mount、update 和 unmount 的。

Chrome 性能分析工具下的组件状态

具体操作如下:

  1. 暂时关掉所有插件,特别是 React DevTools,因为他们会显著影响测试结果(不过根据我的经验来看,如果只是做相对比较的话不需要这样,只是要知道这里有哪些时间是因为插件消耗的)
  2. 确保你的代码处于开发模式
  3. 打开 Performance 标签,开始录制
  4. 进行你想要测试的操作,但最好不要超过 20 秒,否则 Chrome 可能会无响应
  5. 停止录制
  6. 你可以在 User Timing 标签处看到 React 事件

需要注意的是,生产模式下的运行速度会更快一些。但相比于绝对值,我们在开发模式下进行分析的时候,更关注于无关的组件是否被错误地更新了,以及是否更新地太频繁、太深入了。

使用 DevTools 性能分析工具

react-dom 在 v16.5 以后为 Rect DevTools 提供了更强的性能分析工具。官网推荐了一篇 文章 和一个 视频 作为介绍。用法和 Chrome 自己的也差不多。

虚拟化长列表

如果你的应用需要渲染很长的列表数据,而渲染它们又很耗时的话可以试试做成虚拟化。简而言之就是只渲染可视区域部分的数据,随着滚动而不断更新这部分数据。

这样的好处是缩短了渲染组件的时间以及减少了页面上的节点数量。但其实也会引入一些问题。假如你渲染某一条本身就需要挺多时间的,而快速滚动的时候就会来不及渲染。这时用户就只能看到一大片白屏,等一段时间后才会把节点渲染出来。当然,可以通过一些手段来减少快速滚动时渲染节点的开销,但不可能完全消除影响。这可以说是虚拟化的「原罪」。

开源社区已经有很多虚拟化方案了,React 官方推荐了 react-windowreact-virtualized。原理其实挺简单的,我这里也有一篇文章做了介绍:「滴答清单」的虚拟化列表实践

避免 Reconciliation(协调)

什么是 Reconciliation?先看看这篇文章

前面我们介绍了性能分析工具,通过它们我们可以知道影响性能的原因。而经常出现的情况就是,部分组件本来可以跳过更新,却意外地被更新了。怎样快速排查呢?

在开发者工具的 React 标签下(这是由 React DevTools 创建的),我们可以看到 Highlight Updates。勾上它,然后在页面里进行操作,你就会看到一些边框在闪烁,那些就代表着对应的组件发生了更新。

以上面为例,虽然 React 最终并不会更新输入框以外的其他节点,但我们看到其他的组件确实是更新了。为什么呢?我们先来看看 React 的更新机制是怎么样的。




上面这些图的意思是,当 C6 状态发生变化的时候,会不断向上找到根节点 C1,然后 C1 的子节点们就都会触发 re-render。我们写 React 应用的时候一般也就 1 个根节点,也就意味着,只要有一个组件发生了更新,那么整棵树就都会触发到 re-render,不管是否真的需要。

在应用比较简单的时候,这不是个什么大问题。但当你开始注意到这方面的影响了以后,就需要做点额外的工作了。

shouldComponentUpdate

在 React 组件的生命周期里,它为我们预留了 shouldComponentUpdate 这个钩子。它会在 re-render 之前触发。默认返回值是 true。假如你知道,某些情况下你的组件并不需要更新,那么就可以返回 false,以此来跳过 re-render。

SCU: shouldComponentUpdate?

vDOMEq: are virtual DOMs equivalent?

如上图所示,最后就如同我们希望的那样,C2 的 SCU 返回了 false,于是它并不需要进行更新,连带着 C4 和 C5 的 SCU 都不需要触发了。C7 的 SCU 也同样返回了 false。而 C8 的 SCU 返回了 true(我们先假设是这样的),此时又会重新调用一次 C8 的 render 方法。出来的结果和之前的一样,所以事实上 React 也不会去更新它的真实 DOM 节点。

每个组件都要这样写一遍吗?不,React 为我们提供了一个通用方案。

PureComponent

React.PureComponentReact.Component 的区别就在于,PureComponent 重写了 shouldComponentUpdate 方法,使用浅比较的方式对前后的 props 和 state 进行了对比。大概类似于这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function shouldComponentUpdate(nextProps, nextState) {
const thisProps = this.props || {},
thisState = this.state || {};
nextProps = nextProps || {};
nextState = nextState || {};

if (
Object.keys(thisProps).length !== Object.keys(nextProps).length ||
Object.keys(thisState).length !== Object.keys(nextState).length
) {
return true;
}

for (let propsKey in nextProps) {
if (thisProps[propsKey] !== nextProps[propsKey]) {
return true;
}
}

for (let stateKey in nextState) {
if (thisState[stateKey] !== nextState[stateKey]) {
return true;
}
}
return false;
}

不要传入用不到的 props

虽然是一句很简单的废话,「不要传入用不到的 props」,但就实际开发过程来看还是偶尔会有这种问题出现。这里举一些例子:

  • 不要随意用展开符来传 props,就像:<Button {...object} />,当然如果能确保没问题的话也还行
  • 如果使用了 react-redux,对组件使用 connect 传递数据的时候,最好能直接传最终的值,而不是直接从 store 里拿了对象,否则可能会被其他数据干扰。另外,如果传过来的值是需要计算后的对象,推荐使用 reselect 进行缓存

除了 React 介绍的这些与其自身相关的方法以外,还有一些其他方面的优化工作可以考虑,不过这些就和使用什么库、什么框架无关了,只是一些通用的手段。

其他

Web Worker

我们都知道,JS 在浏览器里执行是单线程的。同时,JS 线程工作的时候,UI 线程就会等待。假如 JS 占用了太久的时间,视图就得不到更新,反馈到用户那里就是掉帧、卡顿。而 Web Worker 则提供了一个独立的线程,我们可以在那里进行复杂的计算、与服务器进行通讯等等,再通过 message 与主线程进行数据传输。

减少回流重绘

这个学问就大了。而且,很多时候也恰恰就是因为不合理地使用 CSS,或者使用 JS 对样式做一些改动,导致页面严重卡顿。本文就不再展开。